Skip to content

Augment BooleanAnd falsey and BooleanOr truthy type narrowing when left and right conditions narrow different expression keys#5595

Open
phpstan-bot wants to merge 3 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-58oa332
Open

Augment BooleanAnd falsey and BooleanOr truthy type narrowing when left and right conditions narrow different expression keys#5595
phpstan-bot wants to merge 3 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-58oa332

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

@phpstan-bot phpstan-bot commented May 3, 2026

Summary

When isset($test['hi']) && is_string($test['hi']) was used in a combined && condition with an early return, PHPStan failed to narrow the array union type in the falsey branch. Splitting the condition into nested if blocks worked correctly. The fix recovers the lost type narrowing for both BooleanAnd falsey and the De Morgan dual BooleanOr truthy.

Changes

  • Added augmentDisjunctionTypes() method to src/Analyser/TypeSpecifier.php that computes both disjunction-path filtered scopes and checks if candidate expressions are narrowed in both paths. If so, it creates a sureType with the union of the narrowed types.
  • Called this method in the BooleanAnd falsey branch (after the existing intersectWith) using left-falsey and right-falsey-given-left-truthy scopes.
  • Called this method in the BooleanOr truthy branch (after the existing intersectWith and augmentBooleanOrTruthyWithConditionalHolders) using left-truthy and right-truthy-given-left-falsey scopes.
  • Updated tests/PHPStan/Analyser/data/type-specifying-extensions-2-false.php and type-specifying-extensions-2-null.php: $foo is now correctly string instead of string|null because the test extension fires in all contexts and both disjunction paths narrow it.
  • Updated tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php (testBug3632): after progressive elimination by early returns, $a is now correctly narrowed to null and $b to NiceClass, producing an additional true-positive instanceof error at line 36.
  • Updated tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php (testBug11903): PHPDoc tip removed from line 21's error because the narrowing is now from control flow rather than PHPDoc types.

Root cause

In TypeSpecifier::specifyTypesInCondition, the falsey branch of BooleanAnd (and truthy branch of BooleanOr) uses SpecifiedTypes::intersectWith() to combine type assertions from the two operands. intersectWith only keeps expression keys present in both operand sets. When the left condition narrows $test (the array variable) and the right condition narrows $test['hi'] (an array offset), they have different expression keys, so intersectWith drops both — resulting in no narrowing at all.

The fix adds a post-intersection augmentation step that computes the two actual filtered scopes (one for each disjunction path) and checks if both paths narrow the same expression. If so, the union of the narrowed types is added as a sureType.

Analogous cases probed

  • BooleanOr truthy (De Morgan dual): same information loss pattern, fixed with the same augmentDisjunctionTypes method using truthy scopes instead of falsey scopes. Test added.
  • Other type-check functions: is_array, is_int, is_float, is_bool all tested in combination with isset — all pass with the fix.
  • array_key_exists: tested as an alternative to isset — passes.
  • Conditional expression holders path (via augmentBooleanOrTruthyWithConditionalHolders): already existed for BooleanOr truthy but only handles candidates from pre-existing conditional holders in the scope, not from the current condition's sureTypes. The new augmentation complements it.

Test

  • tests/PHPStan/Analyser/nsrt/bug-14566.php: regression test for the reported bug with isset && is_string on array{}|array{hi: 'hello'}|array{hi: array{0: 42, 1?: 42}}, plus:
    • Nested-if baseline (already worked)
    • Combined && with early return (the reported bug)
    • Combined && with offset assignment (from the issue's playground)
    • || dual case (!isset || !is_string)
    • is_array, is_int, is_float, is_bool variants
    • array_key_exists variant

Fixes phpstan/phpstan#14566
Fixes phpstan/phpstan#13061
Fixes phpstan/phpstan#7259

Comment thread src/Analyser/TypeSpecifier.php Outdated
Comment thread src/Analyser/TypeSpecifier.php
Comment thread src/Analyser/TypeSpecifier.php Outdated
Comment thread src/Analyser/TypeSpecifier.php Outdated
Comment thread src/Analyser/TypeSpecifier.php
Comment thread tests/PHPStan/Analyser/data/type-specifying-extensions-2-false.php
VincentLanglet and others added 2 commits May 10, 2026 11:29
…n left and right conditions narrow different expression keys

- In TypeSpecifier::specifyTypesInCondition for BooleanAnd falsey context,
  the intersection of normalized left and right SpecifiedTypes drops all
  narrowing when the two sides operate on different expression keys
  (e.g. $test vs $test['hi']). Add augmentDisjunctionTypes() to recover
  narrowing by computing both disjunction-path scopes and unioning the
  narrowed types for any expression narrowed in both paths.
- Apply the same augmentation for BooleanOr truthy context, which is
  the De Morgan dual and has the same information loss.
- Update four existing tests whose assertions reflected the old
  incomplete narrowing: type-specifying-extensions-2-{false,null}.php
  (extension fires in all contexts, both paths now narrow correctly),
  bug-3632 (additional true-positive instanceof error detected),
  bug-11903 (PHPDoc tip removed because narrowing is now from control
  flow).
- Add regression tests covering the reported case (isset && is_string
  on array union), the || dual, and parallel type checks (is_array,
  is_int, is_float, is_bool, array_key_exists).
- Rename $leftNorm/$rightNorm to $leftNormalized/$rightNormalized
- Rename $origType to $originalType
- Extract $types->getSureTypes() into $existingSureTypes before the loop
- Early-continue on left narrowing check before computing right narrowing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-58oa332 branch from d2744e3 to f178bf5 Compare May 10, 2026 09:29
@VincentLanglet VincentLanglet requested a review from staabm May 10, 2026 10:03
@VincentLanglet
Copy link
Copy Markdown
Contributor

To me the integration test errors are correct

@ondrejmirtes
Copy link
Copy Markdown
Member

Please look at issue-bot results and decide whether anything else is fixed too

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Added two regression test files:

  • tests/PHPStan/Analyser/nsrt/bug-13061.php — tests that isset($scenarioHash['type']) && $scenarioHash['type'] === 'outline' correctly narrows out the TOutlineHash type in the else branch, so loadScenarioHash() receives only TScenarioHash.
  • tests/PHPStan/Analyser/nsrt/bug-7259.php — tests both the nullable DateTimeImmutable variant and the string|int variant where || at the outer level with nested && conditions and assignments guarantees both variables are non-null / string by the time they're used.

Both make tests (12,038 tests) and make phpstan pass with no errors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants